const { useState } = React; /* Mobile-first — single column, ≤420px content width. Bottom tab bar instead of top nav. Sticky bottom CTAs. Tap targets ≥48px; primary CTA 56px. */ function TopBar({ title, onBack }) { return (
{onBack ? ( ) : ( )}
{onBack ? (
{title}
) : ( <>
Zookoutek
u Nováčků
)}
); } function TabBar({ view, setView }) { const tabs = [ ['catalog', '🧭', 'Trasy'], ['shop', '🛒', 'Obchod'], ['about', '🏡', 'O nás'], ]; return ( ); } /* Strukturované filtry — 4 dimenze odpovídající backend modelu: x_region | x_age (small/big/adult) | x_theme | x_difficulty Mobilně: kompaktní řádek s počtem aktivních + sheet pro detailní výběr. Stavem je objekt { region, age, theme, difficulty, sort }. */ const REGIONS = [ ['jihomoravsky','Jihomoravský'], ['praha','Praha'], ['stredocesky','Středočeský'], ['vysocina','Vysočina'], ['moravskoslezsky','Moravskoslezský'], ['plzensky','Plzeňský'], ['karlovarsky','Karlovarský'], ['ustecky','Ústecký'], ['liberecky','Liberecký'], ['kralovehradecky','Královéhradecký'], ['pardubicky','Pardubický'], ['jihocesky','Jihočeský'], ['olomoucky','Olomoucký'], ['zlinsky','Zlínský'], ]; const AGES = [['small','3–6'], ['big','6–12'], ['adult','12+']]; const THEMES = [ ['priroda','🌿 Příroda'], ['pohadka','🧚 Pohádka'], ['dobrodruzstvi','🗺️ Dobrodružství'], ['vzdelavani','📖 Vzdělávání'], ['historie','🏰 Historie'], ['mesto','🏙️ Město'], ['sport','🏃 Sport'], ]; const DIFFS = [['easy','🟢 Lehká'], ['medium','🟡 Střední'], ['hard','🔴 Těžká']]; const SORTS = [['nearby','📍 Nejbližší'], ['newest','🆕 Nejnovější'], ['rated','⭐ Nejlépe hodnocené'], ['short','🚶 Nejkratší']]; function countActive(f) { return (f.region ? 1 : 0) + (f.age ? 1 : 0) + (f.theme ? 1 : 0) + (f.difficulty ? 1 : 0); } function FilterBar({ filter, setFilter, onOpenSheet, onOpenMap, mapMode }) { const n = countActive(filter); const pillBase = { flex: '0 0 auto', height: 38, padding: '0 14px', borderRadius: 999, fontSize: 13, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap', display: 'inline-flex', alignItems: 'center', gap: 6, }; const sortLabel = (SORTS.find(s => s[0] === filter.sort) || SORTS[0])[1]; return (
{AGES.map(([k, l]) => ( ))}
); } function FilterSheet({ filter, setFilter, onClose }) { const [draft, setDraft] = useState(filter); const set = (k, v) => setDraft(p => ({ ...p, [k]: p[k] === v ? null : v })); return (
e.stopPropagation()} style={{ background: '#f8f5f0', width: '100%', maxWidth: 480, margin: '0 auto', borderTopLeftRadius: 22, borderTopRightRadius: 22, padding: 18, paddingBottom: 'calc(18px + env(safe-area-inset-bottom))', maxHeight: '85vh', overflowY: 'auto', boxShadow: '0 -8px 24px rgba(90,58,30,.18)', }}>

Filtry

{REGIONS.map(([k, l]) => ( set('region', k)}>📍 {l} ))} {AGES.map(([k, l]) => ( set('age', k)}>👶 {l} let ))} {THEMES.map(([k, l]) => ( set('theme', k)}>{l} ))} {DIFFS.map(([k, l]) => ( set('difficulty', k)}>{l} ))} {SORTS.map(([k, l]) => ( setDraft(p => ({ ...p, sort: k }))}>{l} ))}
); } function FilterGroup({ title, hint, children }) { return (

{title}

{hint && {hint}}
{children}
); } function ChipBtn({ active, onClick, children }) { return ( ); } function MapView({ trails, onPick, onClose }) { // Leaflet + Mapy.cz outdoor (same tiles as engine.js) const containerRef = React.useRef(null); const mapRef = React.useRef(null); React.useEffect(() => { if (!containerRef.current) return; if (mapRef.current) { try { mapRef.current.remove(); } catch(e){} mapRef.current = null; } function init() { if (!window.L) { setTimeout(init, 200); return; } const points = trails.filter(t => t.lat && t.lng); const m = window.L.map(containerRef.current, { zoomControl: true, scrollWheelZoom: true }) .setView([49.5, 16.5], 7); window.L.tileLayer( 'https://api.mapy.cz/v1/maptiles/outdoor/256/{z}/{x}/{y}?apikey=nyEN0DoYhZat_CdtIHZ0jnCBq2vqvxOKd0lepCZpPjA', { attribution: '\u00a9 Mapy.cz', maxZoom: 19 } ).addTo(m); const bounds = []; points.forEach(t => { const html = '
' + (t.locked ? '🔒' : t.emoji) + '
'; const icon = window.L.divIcon({ html, className: '', iconSize: [52, 52], iconAnchor: [23, 46] }); const marker = window.L.marker([t.lat, t.lng], { icon }).addTo(m); const safe = (s) => String(s || '').replace(/[<>"']/g, c => ({ '<':'<','>':'>','"':'"',"'":''' }[c])); marker.bindPopup( '
' + safe(t.emoji + ' ' + t.title) + '
' + '
' + safe(t.place || '') + '
' + '' + (t.locked ? '🔒 Detail' : '▶ Otevřít') + '' ); marker.on('click', () => onPick(t)); bounds.push([t.lat, t.lng]); }); if (bounds.length > 1) m.fitBounds(bounds, { padding: [30, 30] }); else if (bounds.length === 1) m.setView(bounds[0], 13); mapRef.current = m; } init(); return () => { if (mapRef.current) { try { mapRef.current.remove(); } catch(e){} mapRef.current = null; } }; }, [trails]); return (
Klepněte na pin pro detail trasy
); } function TrailCard({ trail, onOpen }) { return (
!trail.locked && onOpen()} style={{ background: '#fff', border: '1px solid #d4c4a8', borderRadius: 14, overflow: 'hidden', boxShadow: '0 1px 2px rgba(90,58,30,.06)', cursor: trail.locked ? 'default' : 'pointer', opacity: trail.locked ? 0.6 : 1, }}>
{trail.locked ? '🔒' : trail.emoji}

{trail.title}

{trail.place}
{trail.locked ? 🔒 Brzy : trail.meta.map((m, i) => {m})}
); } const pillStyle = (bg) => ({ display: 'inline-flex', alignItems: 'center', gap: 4, height: 26, padding: '0 10px', background: bg, border: '1px solid #d4c4a8', borderRadius: 999, fontSize: 12, fontWeight: 700, color: '#8a6a4a', }); function CatalogView({ onOpenTrail }) { const [filter, setFilter] = useState({ region: 'jihomoravsky', age: null, theme: null, difficulty: null, sort: 'nearby' }); const [sheetOpen, setSheetOpen] = useState(false); const [mapMode, setMapMode] = useState(false); // Live data from Odoo backend (window.__zooTrails is set by QWeb template) const REGION_LABELS = { jihomoravsky:'Jihomoravský', vysocina:'Vysočina', stredocesky:'Středočeský', praha:'Praha', moravskoslezsky:'Moravskoslezský', plzensky:'Plzeňský', karlovarsky:'Karlovarský', ustecky:'Ústecký', liberecky:'Liberecký', kralovehradecky:'Královéhradecký', pardubicky:'Pardubický', jihocesky:'Jihočeský', olomoucky:'Olomoucký', zlinsky:'Zlínský' }; const _src = (window.__zooTrails && window.__zooTrails.length) ? window.__zooTrails : [ { id: 'demo', slug: 'demo', emoji: '🦙', bg: '#f5ead8', title: 'Demo trasa', place: 'Bosonohy · 1,8 km', region: 'jihomoravsky', ages: ['small','big'], theme: 'priroda', difficulty: 'easy', meta: ['🚶 1,8 km', '⏱️ 45 min', '🏁 6'], state: 'published', lat: 49.19, lng: 16.52, url: '/za-cecilem' }, ]; const trails = _src.map(t => ({ id: t.id, slug: t.slug || ('g' + t.id), emoji: t.emoji || '🧭', bg: t.bg || t.color_bg || '#f5ead8', title: t.title || t.name || 'Bez názvu', place: t.place || ((t.location || '') + (t.distance_km ? ' · ' + t.distance_km + ' km' : '')), region_label: REGION_LABELS[t.region] || t.region || '', region_key: t.region || '', ages: t.ages || [], theme: t.theme || '', difficulty: t.difficulty || '', meta: (t.meta && t.meta.length) ? t.meta : [ t.distance_km ? ('🚶 ' + t.distance_km + ' km') : null, t.time_min ? ('⏱️ ' + t.time_min + ' min') : null, t.station_count ? ('🏁 ' + t.station_count) : null, (t.rating && t.rating_count) ? ('⭐ ' + (typeof t.rating === 'number' ? t.rating.toFixed(1) : t.rating)) : null, ].filter(Boolean), locked: t.state === 'draft' || !!t.locked, state: t.state || 'published', lat: t.lat || t.center_lat || 0, lng: t.lng || t.center_lng || 0, url: t.url || ('/' + (t.slug || ('g' + t.id))), })); const matches = (t) => { if (t.locked) return true; if (filter.region && t.region_key !== filter.region) return false; if (filter.age && !t.ages.includes(filter.age)) return false; if (filter.theme && t.theme !== filter.theme) return false; if (filter.difficulty && t.difficulty !== filter.difficulty) return false; return true; }; const visible = trails.filter(matches); const count = visible.filter(t => !t.locked).length; return (

Trasy

Najděte trasu pro vaši rodinu. U každého stanoviště krátký příběh a jeden úkol.

setSheetOpen(true)} onOpenMap={() => setMapMode(m => !m)} mapMode={mapMode}/> {!mapMode && (
{count} {count === 1 ? 'trasa' : count < 5 ? 'trasy' : 'tras'} {filter.region && `· ${REGION_LABELS[filter.region] || filter.region}`}
)} {mapMode ? setMapMode(false)}/> : (
{visible.length === 0 && (
🦙
Žádná trasa neodpovídá
Zkuste uvolnit filtry.
)} {visible.map(t => onOpenTrail(t)} />)}
)} {sheetOpen && setSheetOpen(false)}/>}
); } function TrailDetailView({ trail, onBack, onStart }) { return (
{trail.emoji}

{trail.title}

{trail.place}
{(trail.meta && trail.meta.length ? trail.meta : ['🚶 1,8 km', '⏱️ 45 min', '🏁 6', '⭐ 4,8', '👶 3–12']).map((m, i) => ( {m} ))}
{trail.description ? (

{trail.description}

) : null}

Stanoviště

{(() => { const allStations = window.__zooStations || {}; const stations = (allStations[trail.id] || []).slice().sort((a, b) => (a.num || 0) - (b.num || 0)); const active = stations.filter(s => s.active !== false); const drafts = stations.filter(s => s.active === false); if (stations.length === 0) { return (
🌱
Tato trasa zatím nemá stanoviště
{trail.locked ? 'Připravujeme.' : 'Přidejte první stanoviště v gps-admin režimu.'}
); } return ( <> {active.length > 0 && (
    {active.map((s, i) => (
  1. {s.num || (i + 1)}
    {s.emoji ? {s.emoji} : null} {s.title || ('Stanoviště ' + s.num)}
    🎧
  2. ))}
)} {drafts.length > 0 && (
📝 Surové body z průzkumu ({drafts.length})
Tyto body teprve čekají na zpracování. Editor z nich vytvoří finální stanoviště s příběhem a úkoly.
    {drafts.slice(0, 12).map((s) => (
  1. {s.num}
    {s.note ? s.note.substring(0, 140) + (s.note.length > 140 ? '…' : '') : (s.title || ('Bod ' + s.num))}
  2. ))} {drafts.length > 12 && (
  3. … a dalších {drafts.length - 12}
  4. )}
)} ); })()}
{trail.locked ? ( trail.survey_url ? ( 🧭 Pomoci s průzkumem ) : ( ) ) : ( )}
); } function ShopView() { const products = [ { emoji: '🍯', title: 'Med z Bosonoh', price: '180 Kč', sub: '450 g · lipový' }, { emoji: '🧀', title: 'Kozí sýr', price: '140 Kč', sub: '200 g · čerstvý' }, { emoji: '🥚', title: 'Vejce z farmy', price: '90 Kč', sub: '10 ks · domácí' }, { emoji: '🧴', title: 'Mýdlo s mlékem', price: '120 Kč', sub: '100 g · ručně' }, ]; return (

Obchod

Pár věcí přímo z našeho zookoutku. Sběrné místo v Bosonohách, zasíláme i poštou.

{products.map((p, i) => (
{p.emoji}
{p.title}
{p.sub}
{p.price}
))}
); } function AboutView() { return (

O nás

Zookoutek u Nováčků je malý rodinný zookoutek v Brně-Bosonohách. Staráme se o pár kravek, kozy, slepice, lamu a další zvířata. Děláme také procházkové trasy pro rodiny s dětmi.

Jsme dobrovolnický projekt. Když něco funguje divně, napište nám.

Kontakt
📍 Bosonohy, Brno
✉️ ahoj@zookoutekunovacku.cz
🌐 zookoutekunovacku.cz
); } function CatalogApp() { const [view, setView] = useState('catalog'); const [trail, setTrail] = useState(null); const inDetail = view === 'catalog' && trail; return (
setTrail(null) : null} />
{view === 'catalog' && !trail && setTrail(t)} />} {view === 'catalog' && trail && setTrail(null)} onStart={() => { window.location.href = trail.url; }} />} {view === 'shop' && } {view === 'about' && }
{ setView(v); setTrail(null); }} />
); } ReactDOM.createRoot(document.getElementById('root')).render();